Wallabagger 源码分析
介绍
Wallabag 是一个 self-hosted 的开源网页离线缓存工具,经过多年发展比较成熟,GitHub 上有 7k star。Wallabagger 是它的浏览器插件,俗称 Clipper,能够直接将浏览中的网页发送到 Wallabag 收藏。
网页离线缓存的重要性不必多说。今天主要聊聊 Wallabagger,最近有人给它提了一个 PR,能够基于渲染好后的 DOM 树提交,这解决了 Wallabag 存在多年的缓存成功率问题。
在本文中,首先介绍 Wallabag 的几种缓存方式,以及 Wallabagger 的新功能实现原理。
在我的个人项目中,我基于 Wallabag 也开发了基于渲染好后的 DOM 树提交的功能,但是效果没有 Wallabagger 的好。大家都是调用同样的 API,为啥效果会不一样呢?这是我核心想搞明白的问题。
Wallabag 网页缓存方式
我对 Wallabag 的代码没有细看,只是粗略地看了一下,说的不对的地方,还请朋友们指正。
按照 Client 向 Server 提交数据的类型,我将缓存方式分为两类:
- URL 缓存
- Rendered HTML 缓存
其中 Client 指的是用户的浏览器,并且安装了 Wallabagger 插件。Server 指的是运行在服务器上的 Wallabag,它是一个PHP 程序。
URL 缓存
Client 将网页的 URL 发送到 Server。Server 根据 URL 再次拉取网页的内容,进行保存。
Wallabag 的一大特色是它在缓存网页的时候,会自动截取网页正文保存,最终效果非常赏心悦目。这个功能基于 Graby 库实现。
Graby
Graby 是 Wallabag 最核心、最有特色的网页提取组件。
Graby 诞生的渊源还挺有趣的,我探索了一番。Graby 与 fivefilters.org 有很大的关系。
fivefilters.org 是一个专门做网页内容提取的机构:
- 它有一系列产品 Feed Control,Full-Text RSS
- 提供了一个免费服务,自动制作 RSS Feed,以及自动提取网页正文
Full-Text RSS 目前是一个收费项目,但是 GitHub 上有开放 Full-Text RSS 8年前的代码。
Full-Text RSS 除了开放早期代码外,还开源了一个项目 ftr-site-config,这是 Full-Text RSS 对不同站点的内容提取规则,没错,上颚有项目 Full-Text RSS 的提取规则是开源的。
这份规则价值非常高,不论是做网页内容供提取,还是 Feed 阅读器,还是 read later 应用,都需要进行网站适配。
Maxiee 注:能够收录进来的站点,往往是因为价值比较高才值得专门做规则,因此也提供了一些高质量站点。
ftr-site-config 的规则再考究下去,是继承了 Instapaper 的规则。当 Instapaper 被出售之后,Instapaper 的规则便不对外提供了,因此该工程称为社区中的活跃成员。
fivefilters.org 还提供了一个工具,用于快速创建 Site config。
底层铺垫完成了,接着再往回梳理。
Graby 库依赖了 graby-site-config ,graby-site-config 是对 ftr-site-config 的 Fork。
Graby 在描述中,称它是对 Full-Text RSS v3.3 版本的 Fork。
Graby 还有一个依赖是 php-readability,Readability 又是一个比较有渊源的库,它最早起源于 Mozilla's Readability.js,用于生成网页的阅读模式,php-readability 是它的 PHP port。原始的 readability.php 弃坑之后,fivefilters.org 维护了一份 fivefilters/readability.php,然后 Graby 的作者 j0k3r 又维护了一份 j0k3r/php-readability。
Wallabag 的提取功能则通过 Graby 提供。
HTML 缓存
在 Wallabag v2 API 中,除了提交 URL 缓存之外,还有一种方式,是直接提交网页的 HTML,Wallabag 不必再服务端再次发起请求,而是直接用 Client 提交上来的 HTML 内容即可。
这种方式,解决了很多问题:
- 很多网站都是 JavaScript 动态站,curl 可能只能拉下来一个骨架屏、或者 Loading,拉不到最终内容
- 如果服务端再起一个无头浏览器,会增大服务端开销
- 如果页面是基于登录内容的,Server 无疑会拉取失败
- 如果网页需要代理,Server 端直接拉也会失败
而用户的浏览器,本身充当 HTTP Client + 浏览器渲染的职责,将渲染后的结果直接提交给 Server 进行内容提取即可。
由此可见,HTML 缓存是更加完美的方案。
Wallabagger 也是在最新的版本中支持了这种方式(1.14.0 只是技术上实现了,使用中还是比较繁琐)。
API api/entries
Wallabag v2 API 提供一个 API 用于页面提交。这个 API 同时支持 URL 缓存和 HTML 缓存。
该 API 接收 3 个参数,url、content、title。如果是 URL 缓存只传 url 即可,如果是 HTML 缓存,则通过 content 传入 HTML 代码。
这里以 Python 为例,给出页面 HTML 提交的代码:
def addHtml(self, url, html, title, author):
print(f'Wallabag addHtml url 缓存 {url=}')
headers = {
'Authorization': 'Bearer {}'.format(self.access_token)
}
data = {
'url': url,
'content': html,
'title': title
}
try:
r = requests.post(
'{}/api/entries.json'.format(URL), headers=headers, data=data)
print(r.json())
except Exception as e:
print('Error adding url')
print(e)
return None, False
if not r.ok:
# TODO: check auth error handling
return None, r.status_code == 401
return r.json()
Wallabagger HTML 缓存原理
接下来时间,重点分析 Wallabagger 的 HTML 缓存原理。因为我发现,Wallabagger 比我自己开发的效果好。这也是我进行代码阅读的原因。
wallabag-api.js
WallabagApi 类封装了 wallabag API。
值得一提的是,wallabag 的 RESTful API 设计非常标准,让人赏心悦目。
IsSiteToFetchLocally
当 Wallabagger 进行页面抓取的时候,Wallabagger 内部维护了一个白名单,白名单命中的页面则进行 HTML 缓存,在 Wallabagger 中称为 FetchLocally。
判断白名单的方法为 IsSiteToFetchLocally。
SavePage
页面保存方法:
SavePage: function (options) {
const content = { url: options.url };
if (this.data.ArchiveByDefault === true) {
content.archive = 1;
}
if (options.title) {
content.title = options.title;
}
if (options.content) {
content.content = options.content;
}
const entriesUrl = `${this.data.Url}/api/entries.json`;
return this.CheckToken().then(a =>
this.fetchApi.Post(entriesUrl, this.data.ApiToken, content)
)
.catch(error => {
throw new Error(`Failed to save page ${entriesUrl}
${error.message}`);
});
},
从 API 这一层,对比我上面的 Python 代码,是一样的。这么说区别在于上层。
background.js
这个文件是具体的业务逻辑所在。
savePageToWallabag
这个方法用于将页面存入 Wallabag,代码分析:
function savePageToWallabag (url, resetIcon, title, content) {
// ……
// if WIP and was some dirty changes, return dirtyCache
const exists = existCache.check(url) ? existCache.get(url) : existStates.notexists;
// 是否 HTML 缓存
const isToFetchLocally = api.IsSiteToFetchLocally(url);
if (exists === existStates.wip) {
if (dirtyCache.check(url)) {
const dc = dirtyCache.get(url);
postIfConnected({ response: 'article', article: cutArticle(dc) });
}
return;
}
// if article was saved, return cache
if (!isToFetchLocally && cache.check(url)) {
postIfConnected({ response: 'article', article: cutArticle(cache.get(url)) });
moveToDirtyCache(url);
savePageToWallabag(url, resetIcon);
return;
}
// real saving
browserIcon.set('wip');
existCache.set(url, existStates.wip);
postIfConnected({ response: 'info', text: Common.translate('Saving_the_page_to_wallabag') });
// 创建 API 所需的 Options
const savePageOptions = {
url: url
};
// HTML 缓存需要传入以下内容
if (isToFetchLocally) {
savePageOptions.title = title;
savePageOptions.content = content;
}
// 调 API 保存
const promise = api.SavePage(savePageOptions);
promise
.then(data => applyDirtyCacheLight(url, data))
.then(data => {
if (!data.deleted) {
browserIcon.set('good');
postIfConnected({ response: 'article', article: cutArticle(data) });
cache.set(url, cutArticle(data));
saveExistFlag(url, existStates.exists);
if (api.data.AllowExistCheck !== true || resetIcon) {
browserIcon.timedToDefault();
}
} else {
cache.clear(url);
}
return data;
})
.then(data => applyDirtyCacheReal(url, data))
.catch(error => {
browserIcon.setTimed('bad');
saveExistFlag(url, existStates.notexists);
postIfConnected({ response: 'error', error: { message: Common.translate('Save_Error') } });
throw error;
});
};
这里面有几个点:
- Content 即网页 HTML 是由外部传入的
- cache 和 dirtyCache 不知道是什么
- postIfConnect 不知道是什么
postIfConnect 与长连接相关,看起来这个插件还能通过长连接进行控制,不过与本文主题无关,可忽略。
三个 cache
有 3 个 Cache:
const cache = new CacheType(true); // TODO - here checking option
const dirtyCache = new CacheType(true);
const existCache = new CacheType(true);
cache
是一个 KV Store。存入的 key 包括 allTags、URL 对应的 HTML。
cache.set('allTags', data);
cache.set(msg.tabUrl, cutArticle(data));
注:这里的 cutArticle 看起来像是裁剪 HTML,实际上只是对 data 对象进行了 copy:
function cutArticle (data) {
return Object.assign({}, {
id: data.id,
is_starred: data.is_starred,
is_archived: data.is_archived,
title: data.title,
url: data.url,
tags: data.tags,
domain_name: data.domain_name,
preview_picture: data.preview_picture
});
}